AWS Secrets Managerを使ってLambdaのシークレットを管理する
AWS Summit 2018 San Francisco でシークレットを簡単にローテーション、管理、取得するAWS Secrets Manager が発表されました。
AWS Lambda のシークレットを管理する場合、従来は AWS KMS で暗号化し、実行時に復号する方法などが採用されました。
AWS Lambda のブループリント(CloudWatchアラームをAmazon SNS経由でSlackに通知する cloudwatch-alarm-to-slack など)もこのアプローチを採用しています。
本ブログでは、 このブループリントにおいて、AWS KMS で暗号・復号する代わりに、AWS Secrets Manager でシークレット管理する方法を紹介します。
作業手順
以下の流れで作業します。
- SlackのWebhookの準備
- AWS Secrets Manager の設定
- Lambda Function の作成
- 動作確認
1. SlackのWebhookの準備
Webhook URL の取得
次のページを参考にSlack のWebhook URL を取得してください。
https://hooks.slack.com/services/XXX/YYY/ZZZ のような形式の URL が発行されます。
cURL から動作確認
Webhook URL をつかってメッセージ投稿できることを確認します。
cURL からリクエストします。
$ URL=https://hooks.slack.com/services/XXX/YYY/ZZZ $ curl -X POST -H 'Content-type: application/json' \ --data '{"text":"This is a line of text.\nAnd this is another one."}' \ $URL ok
Slack で確認します。
投稿できています。
2. AWS Secrets Manager の設定
管理コンソールからSlackのシークレットを登録します。
secret type に "Other type of secrets" を選択し、
- Webhook URL
- メッセージ投稿したい Slack チャンネル
を登録します。
名前・概要を設定します。
名前は「シークレット名/環境」の命名規則で "slack/dev" で登録します。
シークレット情報を定期的に更新する automatic rotation はやらないため "Disable automatic rotation" を選択します。
シークレットを登録すると、このシークレットを取得するクライアントのサンプルコードがあります。
実際にAWS Secrets Manager からシークレットを取得すると、以下のような JSON がかえってきます。
{ "ARN": "arn:aws:secretsmanager:REGION:123456789012:secret:slack/dev-XXX", "Name": "slack/dev", "VersionId": "DUMMY", "SecretString": "{\"SLACK_HOOK_URL\":\"https://hooks.slack.com/services/DUMMY\",\"SLACK_CHANNEL\":\"#general\"}", "VersionStages": [ "AWSCURRENT" ], "CreatedDate": "...", "ResponseMetadata": { "RequestId": "DUMMY", "HTTPStatusCode": 200, "HTTPHeaders": { "date": "Sat, 09 Jun 2018 15:10:52 GMT", "content-type": "application/x-amz-json-1.1", "content-length": "381", "connection": "keep-alive", "x-amzn-requestid": "DUMMY" }, "RetryAttempts": 0 } }
3. Lambda Function の作成
最後に AWS Secrets Manager からシークレットを取得し、Slack にメッセージ投稿する Lambda 関数を作成します。
Lambda のロール
いつもの Lambda 向け権限の他に、AWS Secrets Manager からシークレットを取得するためのポリシーを追加します。
{ "Version": "2012-10-17", "Statement": [ { "Sid": "VisualEditor0", "Effect": "Allow", "Action": "secretsmanager:GetSecretValue", "Resource": "*" } ] }
Python3 プログラム
Python3 で Lambda 関数を作成します。
import boto3 import json import logging import os from urllib.request import Request, urlopen from urllib.error import URLError, HTTPError from botocore.exceptions import ClientError logger = logging.getLogger() logger.setLevel(logging.INFO) def lambda_handler(event, context): logger.info("Event: " + str(event)) message = json.loads(event['Records'][0]['Sns']['Message']) alarm_name = message['AlarmName'] #old_state = message['OldStateValue'] new_state = message['NewStateValue'] reason = message['NewStateReason'] # AWS Secrets Manager からシークレット取得 try: client = boto3.client(service_name='secretsmanager') SecretId = os.environ["SecretId"] # Lambda の環境変数から Secrets Manager のシークレットIDを取得 get_secret_value_response = client.get_secret_value(SecretId=SecretId) except ClientError as e: raise e else: secret = json.loads(get_secret_value_response['SecretString']) SLACK_HOOK_URL = secret['SLACK_HOOK_URL'] SLACK_CHANNEL = secret['SLACK_CHANNEL'] slack_message = { 'channel': SLACK_CHANNEL, 'text': "%s state is now %s: %s" % (alarm_name, new_state, reason) } req = Request(SLACK_HOOK_URL, json.dumps(slack_message).encode('utf-8')) try: response = urlopen(req) response.read() logger.info("Message posted to %s", slack_message['channel']) except HTTPError as e: logger.error("Request failed: %d %s", e.code, e.reason) except URLError as e: logger.error("Server connection failed: %s", e.reason)
ハイライトしている 22-33 行目がキモです。
本番・開発など環境ごとにコードを共有したいため、シークレットIDは Lambda の環境変数から取得します。
そのため、 SecretId = os.environ["SecretId"]
としています。
ほしいシークレット情報はレスポンス(JSON形式)の "SecretString" キーで取得できます。
バリューは JSON を文字列でダンプしたものです。そのため、 json.loads
で JSON に戻して処理します。
secret = json.loads(get_secret_value_response['SecretString']) SLACK_HOOK_URL = secret['SLACK_HOOK_URL'] SLACK_CHANNEL = secret['SLACK_CHANNEL']
Lambda 関数の環境変数
シークレットIDを Lambda の環境変数から取得しているため、環境変数 "SecretId" に登録したシークレットID(今回は "slack/dev")を設定します。
4. 動作確認
最後に動作確認します。
本来であれば、CloudWatch Alarm、SNS などを構築すベきでしょうが、省力化のために、Lambda のテストイベントを利用して、擬似的にトリガーを発生させます。
Lambda テストイベントの登録
{ "Records": [ { "EventVersion": "1.0", "EventSubscriptionArn": "arn:aws:sns:EXAMPLE", "EventSource": "aws:sns", "Sns": { "SignatureVersion": "1", "Timestamp": "1970-01-01T00:00:00.000Z", "Signature": "EXAMPLE", "SigningCertUrl": "EXAMPLE", "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e", "Message": "Hello from SNS!", "MessageAttributes": { "Test": { "Type": "String", "Value": "TestString" }, "TestBinary": { "Type": "Binary", "Value": "TestBinary" } }, "Type": "Notification", "UnsubscribeUrl": "EXAMPLE", "TopicArn": "arn:aws:sns:EXAMPLE", "Subject": "TestInvoke" } } ] }
Lambda の実行
登録したテストイベントを利用して Lambda 関数を「Test」ボタンから実行します。
正常終了すると、Secrets Managerで登録した Webhook/Channel に対応するチャンネルに CloudWatch Alarm の通知がメッセージ送信されます。
KMS 方式と Secrets Manager 方式の違い
最後に KMS 方式と Secrets Manager 方式の違いを考えてみます。
シークレットのアクセス権限
KMS 方式の場合、管理者が KMS:encrypt でシークレットを暗号化します。 シークレットの利用者は KMS:decrypt 権限だけが与えられ、都度、暗号化されたシークレット(ciphertext)を復号して(plaintext)利用します。
Secrets Manager 方式の場合、管理者がシークレットを Secrets Manager に登録します。 シークレットの利用者は secretsmanager:GetSecretValue 権限だけが与えられ、都度、平文のシークレットを取得して利用します。
どちらのケースも、利用者には最小の権限だけを付与すれば運用できます。
シークレット変更時の対応
KMS 方式の場合、 暗号化されたシークレット は Lambda の環境変数で定義されているため、各 Lambda 関数の環境変数を更新する必要があります。
Secrets Manager 方式の場合、シークレットは一元管理され、各Lambda関数はシークレットへのポインター情報を持っているだけです。 そのため、Secrets Manager のシークレットを変更するだけですみます。
また、Secrets Manager の管理するシークレットはバージョン管理されます。クライアントによって、利用するシークレットのバージョンを変えることも可能です。
さまざまなアプリケーションでシークレットを共有して運用ケースでは、Secrets Manager が向いていそうです。
最後に
Lambda 関数から Slack にメッセージ投稿するユースケースを例に、AWS KMS のかわりに AWS Secrets Manager でシークレット管理する方法を紹介しました。
AWS Secrets Manager でシークレットを一元管理し、AWS Secrets Manager からシークレット情報を取得する薄いラッパーを挟むことで、セキュアにシークレット管理しつつ、シークレットの変更にも柔軟に対応できるのではないでしょうか。